rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope - #16143
Conversation
…0112 envelope
`GET /api/v1/meta/:type/:name/references` could refuse in two ways and the two
answers agreed on neither the envelope nor the message:
A protocol cannot answer for this TARGET type (a `field`)
501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"}
B the resolved kernel has no `findReferencesToMeta` at all
501 {"error":{"code":"NOT_IMPLEMENTED","message":"…not available in this kernel"}}
Two facts were lost on A, and both of them are the operator's. The
PRESCRIPTION: A's message names the question that IS answerable ("Ask the
owning object instead: GET /api/v1/meta/object/<owner>/references"), which is
what keeps "the question was never asked" from being read as "nothing depends
on it" in front of an operator whose next click is a delete — ADR-0110 D3, and
`findReferencesToMeta` says the message is prescriptive for that reason in as
many words. On the wire it was replaced by "Internal server error". And the
`code`'s POSITION: `body.error.code` read on B and `undefined` on A, the very
dialect this route's own comment on the B branch warns against.
A reached the wire through `handleRouteError` -> `declaredServerFaultAnswer`,
which is correct for a server FAULT (#11718, #5582) and cannot see that a
producer-declared 5xx might be a deliberate REFUSAL whose message is authored
for the caller. Teaching the relay that distinction would change behaviour for
every producer-declared 5xx at every door; this takes the bounded half instead —
one door re-dressing one refusal in the dialect it already publishes. The
general question is handed back as its own finding.
The arm is keyed to a declared 501 carrying this route's own published refusal
code and a non-empty message, and the catch is scoped to the protocol call
alone, so neither `resolveProtocol` nor the `resolveExecCtx` seam can reach it.
A `sys_metadata` outage's 503 still propagates withheld, flat and logged.
`rest-server-meta-references-refusal-envelope.test.ts` drives both refusals on
one boot and pins the prescription and the `code` position together, with the
withheld-503 and unpublished-501 controls that keep the arm from reading as
"5xx prose is public now". The existing #9327 pin in
`rest-server-meta-read-org-scope.test.ts` reads the code through both dialects
on purpose and is green either way; its comment described the divergence as
standing, so it is rewritten rather than left falsified.
Fixes #15685
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…n in its diff `toContain` on an absent nested message fails with a matcher TYPE complaint rather than with the finding — measured under ablation. `toEqual` + `stringContaining` prints `undefined` against the expected sentence, which is what a reader of a red run needs to see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…n census Three gate-owned follow-ons to the refusal-envelope repair, none of them a behaviour change: - `check:engine-double-contract`: the new fixture's `findOne()` now opens with `assertEngineFindOnePredicate` — a fake looser than `ObjectQL.findOne` is how a dead REST route once shipped with a green suite — and the RETAINED ledger records the new pin so it protects this file from here on. - `check:system-context-census`: inserting the refusal arm and its helper moved every elevation read site below them in `rest-server.ts` (+88 above the route, +109 below it). Ten anchors in `content/docs/permissions/system-context.mdx` re-anchored by the gate's own `--fix`; pure line rot, no row added, removed or re-worded. Part of #15685 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…nt adds 77 -> 77 sites / 99 -> 100 mentions. The `/references` refusal arm adds ONE prose mention and NO call site: its comment records that the arm's catch is scoped to the protocol call alone, so the `resolveExecCtx` seam above it cannot reach the arm whatever that seam declares — the safety claim the narrow catch rests on. The site count not moving is this two-number control working. Part of #15685 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d99611f9360f8612e24e721024ae687b94af4361 && git checkout d99611f9360f8612e24e721024ae687b94af4361
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 932acc3df41e016812cfc17ffe1527fd7b5df538 c747061cfcc05f1db9148db110ace7555bbda24d && git checkout -B drift-repro 932acc3df41e016812cfc17ffe1527fd7b5df538 && git merge --no-ff c747061cfcc05f1db9148db110ace7555bbda24d
node scripts/docs-audit/affected-docs.mjs --json 932acc3df41e016812cfc17ffe1527fd7b5df538
|
Contract review (clause ②) — PASS — PR #16143 at head
|
| # | claim | reading | verdict |
|---|---|---|---|
| 1 | Route (b) — catch the protocol's own refusal at this one door and re-answer in the sibling exit's envelope; (a) relay-wide refusal/fault distinction and (c) leaving 5xx are the maintainer's | Right allocation: (b) restores the door to what findReferencesToMeta's own comment already claimed ("one route, one dialect"); (a) would move every producer-declared 5xx at every door. The residual generality is handed back as a finding, not smuggled. |
correct |
| 2 | notImplementedRefusalAnswer fires only on declared 501 (via declaredHttpStatus, both spellings) + literal code === 'NOT_IMPLEMENTED' + non-empty message; catch scoped to the findReferencesToMeta call alone |
Diff read; a 503 outage still reaches handleRouteError withheld; resolveProtocol / resolveExecCtx cannot reach the arm. Literal-code match honours the #9232 closed vocabulary by construction; the stated cost (a second refusal code falls back to the fault terminal) is visible. |
correct |
| 3 | Message relayed verbatim, [unanswerable_target] prefix included — filed separately (#16145) rather than edited at the transport |
Correct restraint. | correct |
| 4 | Pins: 7 cases, positional envelope assertions, both refusals on the real route table, controls for the 503 fault and an unpublished 501 code; ablation 2 red / 5 green as predicted; the card's own repro command correctly identified as non-discriminating | Discriminating direction. | correct |
| 5 | Census page re-anchored by --fix (10 anchors, pure line rot); engine-double-contract.pinned.json +5 for the new fixture's findOne |
Tool output on os-regen path; gate baseline growth for a new double. | accepted |
② semver
@objectstack/rest patch. Accepted with the note the changeset already carries: a raw HTTP caller branching on the top-level body.code for this route's 501 must read body.error.code — the SDK reads either position, err.code unchanged. The route's written contract was the nested shape; this is a fix restoring it, the same class as #16044 (patch). In the fixed group the level is moot in effect.
③ Boundary flags
- The relay-wide question (a deliberate producer-declared 5xx refusal keeps its prose vs a fault withholds it) is a real, repo-wide contract question the seat says it files as a finding — that card is the maintainer's decision box, not this review's.
- Per-commit trailers say
Part of #15685while the body saysFixes #15685; squash uses the PR body, so the card closes on merge. Noted so nobody re-edits history.
Evidence and landing
Checks on c747061c: 35 success / 10 skipped / 0 red; mergeable_state: clean; check-governed-merges --test on the 8 paths: 0 hits — ordinary queue landing. Clearing, same stroke: needs:contract-review off #15685 and PR #16143 with provenance; then check-clause2-carriers --pair 16143 ⇒ ready + auto-merge SQUASH from this seat.
Generated by Claude Code
Fixes #15685
GET /api/v1/meta/:type/:name/referencescould refuse in two ways, and the two answers agreed on neither the envelope nor the message. Driven through the real route on one boot:field, #9327)501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"}501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}}findReferencesToMetaat all (#9326)501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}}body.error.codeundefinedon A,NOT_IMPLEMENTEDon BNOT_IMPLEMENTEDon bothbody.codeNOT_IMPLEMENTEDon A, absent on BTwo facts were lost on A, and both of them are the operator's.
The prescription. A's message is not decoration —
findReferencesToMetasays so in as many words ("The message is prescriptive per ADR-0110 D3: it names the answerable question"). It tells the operator what to ask INSTEAD, because a field's dependents ARE reachable, through the object that owns it. That sentence is what keeps "the question was never asked" from being read as "nothing depends on it" (ADR-0110 D3, #8896) in front of an operator whose next click is a delete — this door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." On the wire the sentence had become "Internal server error".The
code's position. It read on B and wasundefinedon A — and this route's own comment on the B branch warns against exactly that dialect ("never the bare-string or sibling-codedialects, which makebody.error.codereadundefined"). One route was violating its own written rule at its other exit, reached by a different path.Route taken, and the two not taken
A reached the wire through
handleRouteError→declaredServerFaultAnswer, which keepsstatusandcodeand replaces the prose withINTERNAL_ERROR_MESSAGE. That arm is correct and argued for a server FAULT (#11718, #5582): a fault message may carry driver internals and withholding it is the point. What it cannot see is that a producer-declared 5xx may be a deliberate REFUSAL whose message is authored FOR the caller.Three repairs were possible and only one is a dev's to take:
(b) is also what the producer already believed was happening:
findReferencesToMeta's own comment says its refusal "reuses the ADR-0112 nested envelope and the SAME501 NOT_IMPLEMENTEDcode the sibling refusal on this exact route already returns. One route, one dialect for 'the question was never asked'." The relay was silently making that false. This restores the door to its written contract rather than inventing one.(b)'s known incompleteness is written down rather than left implied, and is handed back as its own finding:
declaredServerFaultAnswerstill cannot distinguish a deliberate refusal from a server fault, so every OTHER producer-declared 5xx refusal in the repo still loses its prose at every door.How narrow the arm is
notImplementedRefusalAnswer(module-scope inrest-server.ts) fires only on a producer-declared501carrying this route's own published refusal code and a non-empty message, and thecatchis scoped to thefindReferencesToMetacall ALONE — so what it can re-dress is mechanically the set of things the protocol raised; neitherresolveProtocolnor theresolveExecCtxseam above it can reach the arm, whatever they declare.Matching the literal code rather than "any declared code" is also how the #9232 vocabulary narrowing is honoured by CONSTRUCTION: an unregistered producer spelling can never reach this exit, so no second copy of
thrownCodeFields' demotion rule is needed and no new door ships an un-narrowed code. The cost is stated in the docblock rather than hidden: a future SECOND refusal code on this route falls back to the flat fault answer until whoever adds it comes here.The producer's message is relayed VERBATIM,
[unanswerable_target]prefix included. Editing it would be a third local opinion at a boundary whose module argues against exactly that; the prefix is the producer's own prose and is filed as a separate observation.declaredHttpStatusis exported fromerror-response.tsso the arm asks the existing two-spelling question (status/statusCode, #7525) instead of re-deriving it. A read, no wire byte moves, anderror-response.tsis not part of@objectstack/rest's package entry, so nothing published changes.Measurement
rest-server-meta-read-org-scope.test.ts -t 'unanswerable-target'reads the code through both dialects on purpose, so it is green under both shapes — before and after. Reproduced: under the ablation below it stays GREEN while the new pin goes red.New pin:
packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts— 7 tests, both refusals driven on the real route table, assertions POSITIONAL because position is half the finding. It pins the prescription reaching the caller,body.error.codereading the same way on both refusals in ONE comparison, and the arm's boundaries: a producer-declared 503 keeps its withheld generic message and its flat body (driver internals asserted absent), and a 501 declaring a code this door does not publish stays on the fault terminal. Anti-vacuity: an answerable target on the same harness still answers200 {"references":[]}, andINTERNAL_ERROR_MESSAGEis pinned to the exact text A used to ship.Ablation, direction predicted in writing before the run: delete the route's re-dress arm, restoring the pre-fix
const result = await …; res.json(result);. Predicted RED on exactly two of the seven — the prescription test and the code-position test — with the other five green and the org-scope file untouched.Observed, exactly that:
The mutation was confirmed on disk before the run (arm call sites 1 → 0, pre-fix spelling restored 0 → 1, blob hash differs from the HEAD blob) and the restore was proved after it (
git diff HEADempty, restored blob byte-identical to the HEAD blob). No build leg:packages/rest/distdoes not exist in this worktree and the pins import./rest-server.jsrelatively, so vitest resolves source.Suite:
pnpm --filter @objectstack/rest exec vitest run— 186 files, 3168 tests, green.pnpm --filter @objectstack/rest run typecheckgreen, and itscheck:test-typecheckhalf compiles the test layer undertsconfig.test.json; all three touched test files were confirmed present in that program withtsc --listFiles, so the coverage claim is measured rather than assumed.Gates, harvested on this PR's head
c747061cfcc, exit codes captured BEFORE any pipe.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 55 commands on the code-only diff and 92 once the doc anchor repair joins it, asserted against its ownReconciliation — 92 famil(ies)line. 90 of 92 exit 0. The two that do not are NOT MEASURED, not passes:check:dual-build-cjs-loadsandcheck:type-check-debtboth exit 3 withPREREQUISITE NOT MET— each reads built output for every package and asks for a whole-repopnpm build, which does not fit a foreground turn on a shared box. CI runs both after its own build. The 36 Artifact rosters were run separately and are not part of that 92: 32 green;check-partof-closing-keyword.mjsandcheck-single-claim-paths.mjsexit 2 =NOT WIRED(noPR_BODY/PR_NUMBERoutside CI — and their pnpm spellings resolve to--self-testonly, which grades the checker's fixtures, not this diff, per #16030);check:react-declaration-parityprints "MANIFEST is not set … This gate did NOT run";check:published-readme-exportsexits 3, same whole-build prerequisite.Three gate-owned follow-ons, each its own commit and none a behaviour change: the new fixture's
findOneopens withassertEngineFindOnePredicateand the RETAINED ledger records the pin; ten anchors incontent/docs/permissions/system-context.mdxwere re-anchored bycheck-system-context-census.mjs --fixafter the arm and its helper moved every elevation read site below them (+88 above the route, +109 below it), pure line rot with no row added, removed or re-worded; and the execCtx census records 77 → 77 sites / 99 → 100 mentions — one prose mention, no call site, which is that two-number control working.Changeset
patchon@objectstack/rest— a bug fix in a released package. Not breaking: nothing an author can write is removed or renamed, no exported surface is withdrawn, and@objectstack/clientalready reads the code from either position (errorBody?.code ?? errorBody?.error?.code), soerr.codeis unchanged for SDK callers whileerr.messageimproves fromInternal server errorto the prescriptive sentence. No**BREAKING**banner and therefore no ADR-0087 disposition marker;check:adr-0087-registrationandcheck:changeset-no-majorare both green above. The changeset body states the position change for raw HTTP callers.Not folded in, deliberately
#15620 (the falsified
getMetaItemscomment on this same door) and #15622 (the unspecified-type sweep's organization forwarding, a decision box) are separate cards on the same door and stay separate. #13753 is the organization-forwarding half and moves no status code, field or message; the one place this PR touches its pin is the #9327 refusal read, whose comment described the divergence as standing and is rewritten rather than left falsified.Fixes #15685above. The per-commit trailers were written before that convention was applied on this branch and the follow-on commits carryPart of #15685; rewriting them would need an amend or a force-push, both of which are off-limits here.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Generated by Claude Code